release-25-06-26#214
Conversation
… CLI, extensions.json-gate Cline/Roo/Kilo (#198) * fix(discovery): gate Cursor/Copilot CLI on binary + Cline/Roo/Kilo on extensions.json (Tier B) Tier-B detector-accuracy follow-up to #193 — fixes the 5 remaining detectors that gated 'installed' on config/globalStorage residue that survives uninstall (or is created by another tool). All 5 doc-verified vs official vendor docs. Extensions (Cline, Roo, Kilo Code): gate on the ext-id being a live entry in <editor>/extensions/extensions.json (the install registry VS Code rewrites on uninstall) via a new shared vscode_extension_helpers.find_extension_in_editor (case-insensitive id — fixes Kilo's lowercase-on-disk kilocode.kilo-code, silently broken on Linux ext4). Dropped the globalStorage check (VS Code won't clean it — microsoft/vscode#119022) and the host-IDE co-check. Roo gains a VSCodium host. CLIs (Cursor CLI, Copilot CLI): gate on the binary. Cursor CLI -> cursor-agent (and fixed the .detect() fallback probing 'cursor' = the IDE launcher, which mis-labeled the IDE as the CLI). Copilot CLI -> the copilot binary (npm + Homebrew, owner- attributed under root); + NEW Linux detector (Copilot CLI was undetected on Linux). install_path is now the binary, so a config_path field carries ~/.copilot and the orchestrator's permissions/skills/ownership key on it (else permissions silently drop). Detection-only, no backend/FE change. Tests: integration-level both-directions per tool/OS incl. globalStorage-residue-without-entry FP-kill, the Cursor IDE-mislabel guard, Copilot hooks-only/machine-global cases, and config_path full-chain attribution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(copilot-cli): skip the X_OK non-exec test on Windows test_non_executable_binary_not_detected asserts a non-executable ~/.local/bin/copilot is not detected, but os.access(X_OK) returns True for ANY file on Windows -> the binary reads executable there and the test fails (Windows CI). The X_OK gate is POSIX-only; gate the test with @unittest.skipIf(os.name=='nt'), matching the existing Claude test_non_executable_binary_not_detected skip. macOS (3.9/3.11/3.12) already green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot-cli): get_version uses self._resolve_binary, not the module resolver (Greptile) get_version() resolved the binary via the module-level _resolve_copilot_binary (per-user only: ~/.local/bin, ~/.bun/bin, nvm), but detection uses self._resolve_binary, which Linux/Windows override to add the npm-global prefix, /usr/local/bin, and AppData npm. So a copilot detected via those OS-specific locations reported version 'unknown' (get_version's fallback shells 'copilot --version' on the scanner PATH, empty under root MDM). Route get_version through self._resolve_binary so version resolution matches detection on every OS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(discovery): close 3 binary-coverage gaps — Cursor Win-native, Copilot WinGet + Linuxbrew Coverage verification vs official install docs/scripts found 3 documented install methods the binary resolvers didn't probe -> false negatives: 1. Cursor CLI Windows: the native installer (irm cursor.com/install?win32) writes %LOCALAPPDATA%\cursor-agent\{cursor-agent,agent}.{exe,cmd} + versions\<v>\, but we probed only %USERPROFILE%\.local\bin\cursor-agent.exe -> every native-Win user missed. Added the LOCALAPPDATA paths (existence-gated) + versioned subdir (numeric-newest) + the Git-Bash extensionless ~/.local/bin/cursor-agent. Hoisted _version_key to module level. 2. Copilot CLI Windows: 'winget install GitHub.Copilot' drops a Links shim; verified vs the winget manifest (Commands: [copilot]) the shim is copilot.exe -> added %LOCALAPPDATA%\Microsoft\WinGet\Links\copilot.exe (mirrors the Claude WinGet path). 3. Copilot CLI Linux: 'brew install copilot-cli' is official on Linux (Linuxbrew) -> added ~/.linuxbrew/bin/copilot (user-relative) + /home/linuxbrew/.linuxbrew/bin/copilot (machine-global, owner-attributed under root via machine_global_binary_owned_by_user -- no cross-user FP). /usr/local/bin now also owner-gated under root. Tests: both directions, hermetic; the 6 positive tests fail against pre-fix code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(copilot-cli): rename internal config_path field -> _config_path (review WARNING) The Copilot CLI detector emits the resolved ~/.copilot dir so the orchestrator can attribute per-user settings/skills/ownership (install_path is the binary now). The field was named 'config_path' (no underscore), so generate_single_tool_report did NOT strip it -> it leaked to the backend payload (exposing the user's home path). Rename to '_config_path' to match the internal-field convention (JetBrains uses _config_path) so it's stripped. Consumed BEFORE the strip (ownership/skills/permissions attribution unchanged), absent from the sent payload. Sites: the emit (macOS copilot_cli, inherited by win/linux) + the 5 orchestrator reads/carry/ log + docstrings. Tests updated; new test_config_path_stripped_from_backend_report asserts the field drives attribution but never reaches the report. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): resolve Cursor/Copilot CLI version from the detected binary, not the scanner PATH Both detectors found the binary correctly (install_path) but fetched the VERSION indirectly — Cursor via a bare 'cursor-agent --version' against the scanner's PATH, Copilot by re-resolving via self.user_home — so under a root/MDM all-users scan the version read 'Unknown' even though the binary was found (root's PATH lacks the user's copy). Thread the already-resolved binary into get_version(self, binary=None) (backward-compatible: no-arg keeps the old behaviour); _detect_cursor_cli passes cursor_agent_bin, Copilot _detect_for_user passes the binary it resolved. Probe '<binary> --version' directly — no re-resolve, no bare-PATH fallback. Preserves doc-verified caveats: the --version FLAG (offline, not the network 'version' subcommand); the multi-line-banner parsers; VERSION_TIMEOUT + swallow-to-unknown; the Windows .cmd shim shell=True path (Cursor Windows quotes the spaced path via list2cmdline; Copilot Windows routes through _probe_version). Tests: root-scan version resolution for both (binary off the scanner PATH / self.user_home unset -> version still parsed from the resolved binary), proven non-vacuous; Windows quoted-path + back-compat; existing version tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: trim verbose/task-specific comments to brief reason-only (PR #198) Comments should convey the WHY, briefly — not the whole story. Removed task/PR/commit/review references (93b5fc2, W1, doc-verified, device serials, follow-up asides), multi-sentence narratives, and restatements of obvious code from THIS PR's added comments; kept the brief non-obvious reasons (globalStorage survives uninstall, shell=True for the npm .cmd shim, which/npm-prefix root-PATH guards, case-insensitive ext-id, Windows X_OK). Comments/docstrings only — no code/logic changed (AST-verified); suite green (1200). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(linux/copilot-cli): don't double-count other users' user-level skills on root scans The Linux Copilot CLI skills extractor called is_user_level_claude_subdir(type_dir) single-arg, which derives the users-root from Path.home(). On an MDM root scan Path.home()==/root (parent /), so a NON-scanner user's /home/<user>/.agents/skills (and .claude/skills) was not recognized as user-level and got re-emitted by the project walk as a project skill -- even though _extract_user_level_skills already emitted it user-scope. Result: duplicate / mis-scoped skill rows on the primary (MDM root) Linux deployment. Add a Linux-aware _is_user_level_skill_dir override (pin users-root to /home for the /home/<user> shape + explicit /root check), mirroring macOS behavior that worked only because all homes share /Users. Ported from PR #157's guard and its TestLinuxRootSkillsUserLevelGuard regression test (this project has no Linux CI runner, so the test is pure path-logic and runs on every runner). Also resolves the open Greptile P2s on this PR: - remove dead _check_ide_installation + now-unused imports (linux/cline, linux/roo_code) and the dead method + orphan IDE_APP_NAMES/APPLICATIONS_DIR (macos/kilocode), left over from dropping the host-editor AND-gate - fix stale class docstrings still describing globalStorage gating (macos/cline, macos/roo_code) and install_path=~/.copilot (windows/copilot_cli) 1269 tests pass (+3 new). Detection-only, no backend/FE change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: audit <audit@local>
…(WEB-4673) (#167) * fix(windows/copilot-cli): resolve version + dedup MCP on admin scans (WEB-4673) Two Windows-only discovery bugs on the admin all-users path: 1. Version always "unknown": get_version() only ran bare `copilot` on PATH, but in an admin scan the scanner's PATH lacks each scanned user's npm bin. Now probe the detected user's own shim (<user_home>\AppData\Roaming\npm\copilot.cmd /.exe) first, scoped to self.user_home, then fall back to PATH. Also set self.user_home per-iteration in the admin all-users scan so the probe is scoped there too. 2. MCP servers double-counted: extract_ide_global_configs_with_root_support and extract_dual_path_configs_with_root_support iterate every C:\Users profile (incl. the admin's own) AND then re-add Path.home(). Correct on macOS/Linux (root home is outside the users root) but double-counts on Windows. Added _own_home_already_scanned() guard to skip the re-add when home is already covered by the scan. Tests: 6 new (per-user version probe + PATH fallback + no-double-count + still-adds-root-home-when-outside). Full suite 828 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * address review: guard remaining two MCP helpers + log resolved version (WEB-4673) Review on #167: - extract_claudeai_mcp_servers_with_root_support and extract_claude_plugin_mcp_configs_with_root_support had the same Windows double-count (re-scan the admin's own home after it was already covered by the C:\Users loop). Apply the same _own_home_already_scanned() guard so Claude.ai and plugin MCP servers aren't duplicated on admin scans. - Greptile P2: the per-user shim probe now logs the resolved version + which binary it came from on success, matching the failure-path logging. Tests: +2 (claudeai/plugin no-double-count). Full suite 830 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(discovery): detect home-rooted project-scope .mcp.json files (#166) * fix(discovery): detect home-rooted project-scope .mcp.json files The Claude Code project-scope MCP walk skipped a `.mcp.json` located directly in a user's home directory (project root == home, e.g. C:\Users\<u>\.mcp.json, /Users/<u>/.mcp.json, /home/<u>/.mcp.json). `is_home_dotdir_descendant` — intended to skip the *contents of* hidden home tool dirs (~/.cursor/, ~/.codex/) — also matched a home-rooted leaf `.mcp.json`, so its servers were never reported. On a real device a user-scope server in ~/.claude.json was detected while project-scope servers in ~/.mcp.json (e.g. policycenter, playwright) were missed. Fix: the walk's file branch now tests the file's parent directory (`is_home_dotdir_descendant(entry.parent)`). A home-rooted `.mcp.json` is read, while a `.mcp.json` inside a hidden home tool dir (~/.cursor/.mcp.json) stays skipped. The directory-recursion branch and the sibling directory-based walks (generic + skills) are intentionally unchanged. Purely additive: for any path with >=5 segments old and new are identical; only the 4-segment home-rooted leaf flips skip->read. No path flips read->skip, so no previously-detected config can disappear. Add tests/test_mcp_home_rooted_config.py: predicate regression tests (POSIX + Windows-drive shapes), a walk-level test asserting the file branch consults entry.parent (not the file), and a smoke test for normal projects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(discovery): union MCP servers on path collision + cover Copilot CLI Addresses principal-engineer review of the home-rooted .mcp.json fix. H1: un-skipping a home-rooted ~/.mcp.json makes it emit a project entry whose path == the home dir, which can collide with the ~/.claude.json projects[<home>] (local-scope) entry. The merge functions overwrote mcpServers by path (last-writer-wins), silently dropping one source's servers. Add AIToolsDetector._union_mcp_servers (dedupe by name, first/higher-precedence wins) and apply it at all three merge sites: _merge_claude_mcp_configs_into_projects, _merge_mcp_configs_into_projects, and the Copilot CLI inline merge. Also closes the pre-existing sub-folder collision and stops additionalMcpData from clobbering an existing entry. H2: Copilot CLI's Workspace .mcp.json uses the same project-scope walk, so the home-rooted fix applies to it too; its inline merge now unions as well. Tests: union helper, two-source home collision through the real Claude merge, default-merge union, single-source-unchanged, Copilot shared-walk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: detect built-in VS Code Copilot (all OS) + macOS Codex rules crash — direct to main (#169) * fix(macos/github-copilot): detect built-in VS Code Copilot so its MCPs surface VS Code now ships GitHub Copilot / Copilot Chat as BUILT-IN extensions in the app bundle, which never appear in the per-user ~/.vscode/extensions/extensions.json the detector reads. So users on built-in Copilot were never detected, and their VS Code MCP servers (Code/User/mcp.json) were silently skipped. _detect_vscode_for_user now falls back to scanning the VS Code app bundle's built-in `copilot`/`copilot-chat` extension when no marketplace extension is present, reading the version from package.json. Gated on the user actually having a Code/User data dir so a machine-wide install isn't attributed to unrelated users in a root scan. Detected "GitHub Copilot (VS Code)" then routes through the existing MCP extractor, surfacing Code/User/mcp.json servers. Verified on a real macOS box: built-in Copilot 0.51.0 detected -> 3 user MCP servers (github-mcp-server, context7, playwright-mcp) extracted. Also adds a regression test for the Codex should_process_directory('/' branch) arg-count crash (the code fix already landed on staging; this guards it). Full suite 835 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * review: log built-in Copilot detection outcomes + document single-result return Addresses PR #169 review (4/5): - _detect_vscode_builtin_copilot now logs all three outcomes (no VS Code data dir, built-in found w/ version+path, VS Code-but-no-built-in) at debug, so an admin scan resolving from a non-obvious app bundle is reconstructable. - Documents the intentional at-most-one-result contract: one detection suffices to trigger downstream rules/MCP extraction; built-in Copilot bundles chat in the same `copilot` extension, so a second `copilot-chat` row would only double-process and duplicate MCP servers (unlike the marketplace path where the two are separate installs). No behavior change. Detection tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(win/linux github-copilot): detect built-in VS Code Copilot (parity with macOS) Extends the macOS built-in Copilot detection (this PR) to Windows and Linux, so users on built-in VS Code Copilot — and their VS Code MCP servers (%APPDATA%\Code\User\mcp.json / ~/.config/Code/User/mcp.json) — are no longer missed on those OSes either. - linux/github_copilot: _detect_vscode_for_user falls back to the VS Code install tree (deb/rpm, /opt, snap; stable + Insiders) when no marketplace extension is present, gated on a ~/.config/Code/User data dir. - windows/github_copilot: same fallback over per-user (LocalAppData\Programs) and system (Program Files) installs, gated on %APPDATA%\Code\User. Also fixes an early-return that skipped detection entirely when .vscode\extensions was absent (the exact built-in-only case). - Both log detection outcomes at debug and return at most one entry (a 2nd copilot-chat row would only duplicate the same MCP servers). Adds Linux + Windows detection tests. Full suite 839 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden: guard non-dict package.json in built-in Copilot version read (audit P2) A bundled copilot/package.json that parses as valid JSON but isn't an object (array/string/number) made data.get("version") raise AttributeError, which escapes the per-user loop and aborts detect_copilot() mid-iteration — silently dropping ALL Copilot results (marketplace + built-in, every user) for the run. Add an isinstance(data, dict) guard in _read_extension_version (macOS/Linux) and the Windows built-in version read. Regression test with a non-dict package.json. Full suite 840 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(macos/codex): pass root_path to should_process_directory (silent 0-rules crash) main lacks the staging-only fix (#163): should_process_directory(dir_path) was called with one arg but the helper requires (directory, root_path), raising a TypeError on every '/' scan that was swallowed into 0 Codex project rules. Included here so this main-targeted release carries both the fix and its regression test (test_codex_rules_extraction.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(codex): exercise the real should_process_directory (not a stub) Audit note: the regression test stubbed should_process_directory and only asserted call arity, so it never exercised the real TypeError. Call THROUGH to the real helper instead — a future signature/arity regression now raises the actual production TypeError in the test (verified: fails with "missing 1 required positional argument: 'root_path'" when reverted to the one-arg call), while still pinning the (directory, root_path) contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(github-copilot vscode): prove built-in fallback is purely additive Add per-OS regression tests asserting that when a MARKETPLACE Copilot extension is present, _detect_vscode_for_user returns it and the new built-in fallback is never invoked (spy.assert_not_called) — locking in that existing detection behavior is unchanged and the built-in path only runs when nothing was found. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(codex): make '/'-branch test cross-platform (fix Windows CI) The codex regression test drove the macOS extractor's root_path==Path('/') branch and asserted AGENTS.md was found. On Windows CI a C:\ temp path can't be made relative to '/' (the walk's relative_to raises ValueError, skipping items), so the file-finding assertion failed there — a test-only POSIX assumption, not a product bug (the macOS extractor's '/' scan is POSIX-only). - Keep the cross-platform contract check (should_process_directory called with (directory, root_path)); guard the AGENTS.md assertion to non-Windows. - Add a cross-platform test exercising the REAL helper: one-arg call raises TypeError (the bug), two-arg returns bool — no '/' walk involved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(github-copilot vscode): label built-in as "GitHub Copilot Chat (VS Code)" (#172) * fix(github-copilot vscode): label built-in as "GitHub Copilot Chat (VS Code)" Follow-up to the built-in detection (#169). VS Code consolidates Copilot into the `copilot` extension folder, whose manifest is name="copilot-chat", displayName="GitHub Copilot Chat" ("AI chat features powered by Copilot") — it's the Copilot Chat extension, and the one that consumes mcp.json. The built-in detection was hardcoding "GitHub Copilot (VS Code)", mislabeling Chat as the inline-completions product. Derive the name from the bundle's package.json: name contains "copilot-chat" (or displayName contains "chat") -> "GitHub Copilot Chat (VS Code)" (matching the marketplace github.copilot-chat mapping); a plain "copilot" stays "GitHub Copilot (VS Code)". Marketplace paths unchanged. Per-OS tests updated + a plain-copilot generic case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * review: tighten chat heuristic + add Linux/Windows plain-copilot tests Addresses Greptile (4/5) on #172: - Narrow the displayName check from "chat" to "copilot chat" in all 3 detectors, so a hypothetical future variant like "GitHub Copilot (chat enabled)" isn't mislabeled as Chat. Still matches the real displayName "GitHub Copilot Chat". - Add the plain-copilot generic-label test to the Linux and Windows classes (was macOS-only), so all 3 platforms cover both the chat and plain branches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(github-copilot): attach shared ~/.copilot skills to the VS Code Copilot surface (#173) * feat(github-copilot): attach shared ~/.copilot skills to the VS Code Copilot surface An IDE-only GitHub Copilot user's shared ~/.copilot/skills were read by nobody: the CLI skills extractor owns ~/.copilot but the CLI isn't detected (skills/ is a SHARED marker excluded from CLI detection by #164), and the IDE Copilot branch read nothing from ~/.copilot + has no skills extractor. This enriches the detected VS Code Copilot row with the shared skills it actually consumes (VS Code Agent Skills docs) — EXTRACTION-ONLY; detection and the #164 markers are untouched, so it cannot re-introduce the CLI false positive. - S6: memoized _get_copilot_cli_skills() — one filesystem walk per scan, shared by the CLI + VS Code branches; CLI output byte-identical. - S2: _set_canonical_vscode_copilot() picks ONE VS Code row (prefer Chat), computed from the full detected list; only that row carries skills. - S5: user-scope skills keyed by each skill's own owner-home (from file_path) so multi-user/MDM scans don't leak skills across users. - JetBrains excluded; Linux a graceful no-op (no CLI skills extractor). - copilot-instructions.md + mcp-config.json stay CLI-only; ~/.copilot instructions deferred to a follow-up. Scope: skills only. detect_copilot.py / copilot_cli.py / backend untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): make skill owner-home derivation OS-independent _copilot_skill_owner_home ran the skill file_path through pathlib and returned str(parent.parent). On a Windows interpreter, pathlib re-emits a POSIX-style path with backslashes (str(WindowsPath('/Users/a')) -> '\Users\a'), so the projects_dict key no longer matched the raw forward-slash keys used everywhere else — failing the per-user keying assertions on Windows CI (test_github_copilot_vscode_skills, 3 cases). Derive the owner home by string-slicing at the .copilot/.agents marker instead, preserving the input's separator style on any interpreter. macOS and native-Windows output are byte-identical to before; only the cross-OS (POSIX-path-on-Windows) case is corrected. Detection, the CLI branch, copilot_cli.py and detect_copilot.py are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): address PR review — surface swallowed skills failure + dedup user skills Two greptile review findings on the VS Code Copilot skills enrichment: - P2: _get_copilot_cli_skills swallowed extraction failures at DEBUG, but the accessor is memoized so the CLI branch's existing WARNING never fired for a walk failure — invisible at the prod INFO floor. Upgraded to WARNING, matching the sibling skills-extraction error log. - P1: user-scope skills were appended without dedup while project-scope skills 10 lines above call _deduplicate_project_items. Dedup the owner-home bucket to match (by file_path). IDE branch only; CLI branch output unchanged. Adds test_duplicate_user_skills_deduped_on_owner_home. The third finding (per-sub-flow observability metric) was declined on the PR — inconsistent with the sibling rules/MCP/permissions sub-steps, out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot-cli): demote hooks/ to SHARED — Unbound's own MDM hook caused fleet-wide phantom CLI (#174) * fix(copilot-cli): demote hooks/ to a SHARED marker (Unbound's own MDM hook creates it) #164 split ~/.copilot markers into STRONG (CLI-exclusive) vs SHARED, but kept `hooks/` as STRONG. It is NOT CLI-exclusive: Unbound's OWN MDM onboarding (websentry-ai/setup copilot/hooks/mdm/setup.py) runs for EVERY onboarded device and does `(~/.copilot/hooks).mkdir(parents=True)` then writes unbound.json + unbound.py — creating ~/.copilot/hooks/ from scratch on machines that never had the CLI. So `hooks/` alone triggered a phantom "GitHub Copilot CLI" install fleet-wide. Confirmed in prod: device D2FJV74J5Q / user gowshik — ~/.copilot held only hooks/unbound.json, `copilot --version` = not installed, no config/permissions. Fix mirrors #164's ide/ demotion: move `hooks` STRONG->SHARED so it can never alone declare a CLI install (it still enriches a real install). False-negative- safe: a genuine CLI always also has a strong marker (config.json / session-store.db / logs/). Detection of real installs is unchanged. Adds regression tests: hooks/-only ~/.copilot is suppressed (gowshik repro), and a real install with hooks/ + a strong marker is still detected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * address greptile review: complete the SHARED-marker docstring + Windows hooks guard Two non-blocking gaps flagged on the PR: 1. `_copilot_dir_has_shared_artifact` docstring enumerated a stale shared-marker list that omitted both `ide/` (since #164) and the newly demoted `hooks/`, and wrongly attributed all shared markers to the IDE. Rewrote it to list all six (skills/agents/instructions/copilot-instructions.md/ide/hooks) with their true origins: IDE-read, IDE-written (ide/), and Unbound-MDM-written (hooks/). 2. The Windows detection test class had no hooks-specific assertion. Added `test_hooks_dir_alone_not_detected` to the existing TestWindowsCopilotCliDetection so the SHARED demotion is guarded on Windows too — catches a future regression if the MacOSCopilotCliDetector inheritance is ever broken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot-cli): stop per-user over-attribution + extractor over-collection (#175) Two independent extraction/attribution bugs found alongside the Copilot CLI phantom-detection work (#164/#174). Detection and marker sets are untouched. FIX #2 — per-user over-attribution. The CLI's install_path is a per-user ~/.copilot owned by exactly one user, but main()'s per-user loop re-emits every detected tool for every OS user. filter_tool_projects_by_user scopes a tool's projects/permissions to the user but never rewrites install_path, so a second user (e.g. gowshik_2) who never had ~/.copilot still got a phantom "GitHub Copilot CLI" row pointing at gowshik's home with 0 projects. Add an ownership gate at the per-user emit site (CLI only): emit iff the user owns the detected install OR the filter produced data for them. Extract the path-normaliser to a module-level _normalise_path shared with filter_tool_projects_by_user (DRY). Scoped to the CLI — IDE tools legitimately share a machine-wide install_path. FIX #3 — extractor over-collection. The rules/skills project walks descended into OTHER tools' per-user config dirs and their installed-extension packages (e.g. ~/.antigravity/extensions/<pkg>/.github/instructions/*), mis-attributing those bundled files to Copilot CLI. Add traverses_other_tool_config_dir() and skip those dirs in both the rules walk (macOS + Windows _should_skip) and the skills walk — while still allowing the shared .claude/.agents skill dirs a real repo root legitimately carries (Agent Skills convention). Also dedupe repo-root rule files by realpath + content so an AGENTS.md symlinked to / copied as CLAUDE.md emits once. NOTE: CLAUDE.md/GEMINI.md remain collected — the official GitHub Copilot CLI custom-instructions reference confirms the CLI reads them at the repo root; only the symlink/copy double-count is removed. Tests: tests/test_copilot_cli_per_user_attribution.py (12) and tests/test_copilot_cli_overcollection.py (10). Full suite 902 green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): per-user version probe + documented VS Code instructions/prompts dirs (H2/H4/H5) (#176) * fix(copilot): per-user version probe + documented VS Code instructions/prompts dirs Three doc-verified GitHub Copilot discovery gaps (H2, H4, H5). H3 from the same audit was REFUTED against official docs and is intentionally excluded — see PR body. Detection/markers and MCP extraction are untouched. H2 (CLI) — version="unknown" on root/MDM scans. get_version() probed a bare `copilot` on the scanner's PATH; root's PATH lacks the per-user install, so the version always read "unknown" on MDM all-users scans (an existing TODO flagged this). Resolve the per-user binary from self.user_home first — ~/.local/bin, ~/.bun/bin, ~/.nvm/versions/node/*/bin (macOS, X_OK-checked like find_claude_binary_for_user); AppData/Roaming/npm/copilot.cmd, .local/bin and .bun/bin .exe (Windows, shell=True for the .cmd shim) — then fall back to the bare-PATH probe (zero regression for the running-user case). Best-effort; still degrades to "unknown". H4 (VS Code) — wrong instructions dir. Read the documented .github/instructions/**/*.instructions.md (recursive, depth-gated) instead of the undocumented, over-broad .github/copilot/*.md. Removing the legacy path is a deliberate collection-scope reduction (consistent with #175's anti-over-collection direction); pinned by a negative test. H5 (VS Code) — prompt files never collected. Collect *.prompt.md from project .github/prompts/ and the user Code/User/prompts/ dir (already opened but globbed only *.instructions.md). Prompt files are emitted as ordinary project/user-scoped rule dicts with NO extra fields, because the backend silently discards any rule carrying a non-allowlisted key — locked down by test_prompt_rule_has_only_allowed_fields. find_github_copilot_project_root generalized to walk to the nearest .github ancestor (serves nested instructions + prompts) without regressing the prompts/intellij/AGENTS.md branches. Windows mirrors macOS. Tests: new tests/test_github_copilot_instructions_prompts.py + extended test_copilot_cli_discovery.py and test_scanning_enhancements.py. Full suite 916 pass; the 1 failing test (test_main_cli_with_queue_drain) is a pre-existing environmental flake that also fails on clean main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(copilot): gate POSIX-only version stub tests to fix Windows CI test_version_resolved_from_local_bin_stub / _from_nvm_stub create a #!/bin/sh executable stub and assert the macOS detector probes it. Windows can't exec a shebang script, so get_version() returned None there (Windows CI red). The exec path is inherently POSIX; the Windows per-user-binary path is already covered portably by test_version_probed_from_per_user_npm_shim (which mocks subprocess.run). Gate the two stub-exec tests with skipUnless(os.name=="posix"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): resolve newest nvm Node version for CLI binary (greptile P2) _resolve_copilot_binary iterated nvm version dirs in arbitrary glob order, so a user with multiple nvm-managed Node versions (each with a copilot install) could resolve a stale one. Sort by NUMERIC (major,minor,patch) parsed from the dir name, newest first — note a plain string sort (greptile's suggestion) orders "v9" after "v10"; the numeric key fixes that. Also makes a re-scan deterministic. Adds a POSIX-gated test (macOS resolver) asserting v18 wins over v10/v9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add Sync Staging with Main workflow (#179) Keeps staging in sync with main after each release to main. Triggers on push to main (and workflow_dispatch). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot-vscode): read .claude/rules + user ~/.copilot|.claude instructions; scope MCP per IDE surface (#178) * fix(copilot-vscode): read .claude/rules + ~/.copilot|.claude user instructions; scope MCP per IDE surface Two related GitHub Copilot VS Code Chat discovery fixes (macOS + Windows; Linux tracked separately). Verified against the official VS Code Copilot custom-instructions docs. (1) Rules completeness — the VS Code rules extractor now reads the three documented "Default file location" custom-instruction sources it was missing: - workspace .claude/rules/**/*.md (Claude format) - user ~/.copilot/instructions/**/*.instructions.md - user ~/.claude/rules/**/*.md find_github_copilot_project_root now resolves the nearest .github/.claude/.copilot ancestor (keys these to the right repo root / user home) without regressing the prompts/intellij/AGENTS.md branches. New _extract_claude_rules helper + add_user_rules refactor. Guards: skip the user-home ~/.claude (collected as user scope, no double-count since add_rule_to_project doesn't dedupe) and skip other-tool config dirs / installed extension packages (traverses_other_tool_config_dir). (2) MCP identity scoping — extract_mcp_config was identity-blind: it unioned VS Code global + JetBrains global + workspace .vscode/mcp.json and returned that to EVERY Copilot row, so on a machine with both IDE Copilots each row showed the other's servers. Now gated by tool_name: a VS Code row gets VS Code global + workspace; a JetBrains row gets JetBrains global only; tool_name=None keeps the legacy union (back-compat). Pure narrowing — single-IDE users unaffected. Call site passes the detected row name. Mirrors the already identity-aware rules extractor. Tests: all regression coverage lives in the one focused file tests/test_github_copilot_instructions_prompts.py (.claude/rules project + allowlist guard + extension-dir-skipped + user-home-not-double-collected; user ~/.copilot/instructions + ~/.claude/rules; MCP identity scoping). Full suite green; the lone failure (test_main_cli_with_queue_drain) is a pre-existing environmental flake. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot-vscode): Linux MCP tool_name param (regression) + user-rules depth guard Follow-up to the review of this PR. - CRITICAL: the call site passes extract_mcp_config(tool_name=...) for all OSes, but the Linux GitHub Copilot MCP extractor's signature was still extract_mcp_config(self) -> TypeError (swallowed) -> Linux Copilot MCP servers returned empty (a regression vs main). Linux now accepts tool_name and applies the same VS Code / JetBrains surface gating as macOS/Windows. - add_user_rules now depth-gates its globs (~/.copilot/instructions/**, ~/.claude/rules/**) with MAX_SEARCH_DEPTH, matching _extract_claude_rules (macOS + Windows). - Windows user-rules debug log relabeled "Found VS Code Copilot user rule" (it now also covers ~/.copilot/instructions and ~/.claude/rules). Note: the MCP JetBrains gate stays the simple `not is_vscode` (correct for every github_copilot surface the detector emits today); a speculative positive predicate was considered and dropped as over-engineering per principal review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WEB-4703: VS Code Copilot MCP — JSONC-tolerant parse + remove dead globalStorage fallback (#183) (#184) * WEB-4703: VS Code Copilot MCP — JSONC-tolerant parse + remove dead globalStorage fallback (fixes #1, #3) Fix #1: the GitHub Copilot (VS Code) MCP extractor's _read_mcp_config called json.loads() directly despite a docstring claiming it stripped comments, so any hand-edited mcp.json with // or /* */ comments or a trailing comma threw JSONDecodeError and silently yielded 0 servers. Now strips comments + trailing commas before parsing, reusing the existing string-aware helpers. Fix #3: removed the dead globalStorage/ms-vscode.vscode-github-copilot/mcp.json fallback. That publisher/extension id does not exist (real ids are GitHub.copilot / GitHub.copilot-chat) and VS Code never stores MCP config in extension globalStorage, so the branch could never match a real install. DRY: relocated _strip_jsonc_comments / _strip_trailing_commas into the shared mcp_extraction_helpers.py as the single source of truth; macos/copilot_cli re-exports them for back-compat. Applied across all three OS variants. Fix #2 (profiles / Insiders / legacy settings.json MCP locations) is intentionally deferred to a follow-up PR. * Trim verbose comments on JSONC strippers Condense the explanatory block comments in mcp_extraction_helpers.py to concise one-liners; no code change. * Drop ticket/task-specific references from test comments Remove WEB-4703 / "fix #1" / "fix #3" labels from the JSONC test module docstrings and section comments; keep the behavioral descriptions. No test change. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WEB-4703: VS Code Copilot MCP — read named profiles + Insiders (#185) (#188) * WEB-4703: VS Code Copilot MCP — read named profiles + Insiders (fix #2, scoped A+B) The GitHub Copilot (VS Code) MCP extractor read only the default-profile Code/User/mcp.json. Since VS Code 1.102 MCP is per-profile, so any user on a named profile (Code/User/profiles/<id>/mcp.json) had their servers missed; VS Code Insiders users were also skipped even though detection already counts them (detect_copilot._VSCODE_USER_DATA_DIRS). Adds a shared, bounded, crash-safe enumerator enumerate_vscode_mcp_files() that yields the default mcp.json plus sorted profiles/*/mcp.json for one Code/User base. Each OS extractor now iterates [Code/User, Code - Insiders/User] through it and attributes each config to its own dir (str(mcp_file.parent)), so distinct profiles/variants surface as distinct sources. Customer-agnostic and additive: a machine with only the default-profile mcp.json produces byte-identical output. Reuses the JSONC strippers from #183. Scoped to A+B. Fix C (legacy settings.json mcp key) is intentionally deferred: 1.102 auto-migrates settings.json -> mcp.json, so the remaining population is small and shrinking, and it would add dedup/precedence complexity that profiles + Insiders (disjoint locations) do not need. Predecessor: #183 (fixes #1 + #3). * Log at debug when enumerate_vscode_mcp_files skips on FS error Addresses Greptile P2: the two except blocks swallowed PermissionError/ OSError silently; log at debug to match the rest of the extraction layer (still never raises). No behavior change to returned files. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WEB-4702: fall back to per-uid state dir when home discovery-cache.json is unreadable (#186) (#189) * WEB-4702: fall back to per-uid state dir when home discovery-cache.json is unreadable On a shared-HOME macOS host where discovery runs under two uids -- classically a root MDM agent and the login user on the same machine (the failing host is an EC2 Mac) -- discovery-cache.json is written 0600 by whichever uid runs first. The other non-root uid then gets PermissionError [Errno 13] reading it. Surfaced as Sentry DISCOVERY-TOOL-SCRIPT-11. _ensure_state_dir() already falls back to a per-uid /var/tmp/unbound-{uid} dir, but _try_state_dir() only probed the *directory's* writability, never whether an existing discovery-cache.json was *readable*. A home whose dir is writable but whose cache file is a foreign-owned 0600 was accepted as healthy, then read_cache() raised EACCES on it. Probe the cache file's readability too: a candidate holding a cache file this uid cannot read is rejected, so the resolver falls through to the per-uid temp dir -- the same uid-namespacing utils._get_queue_file_path() already uses for the queue file. os.access() uses the real uid, so root (which can read any file) keeps using its own home cache unchanged, leaving root/all-users scans intact. Scope: this fixes the cross-uid collision and the file-readability probe gap. Downgrading the *expected* permission warning that read_cache/atomic_write_cache emit to Sentry is tracked separately and intentionally not included here. Fixes DISCOVERY-TOOL-SCRIPT-11 * WEB-4702: surface fallback reason in state-dir warning; condense probe comment Address PR review: _ensure_state_dir's fallback warning now includes last_lock_error, so a fall-back to the per-uid temp dir logs *why* the home dir was rejected (unreadable cache vs. unwritable dir vs. OSError) before it is cleared. Also condense the readability-probe comment to a single line. * WEB-4702: trim verbose comments in the cache-fallback test Condense the readability-fallback test's comments to match the source cleanup. No behavior change. --------- (cherry picked from commit 87114e8) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(discovery): Tier-B detector accuracy — binary-gate Cursor/Copilot CLI, extensions.json-gate Cline/Roo/Kilo (#198) (#201) * fix(discovery): gate Cursor/Copilot CLI on binary + Cline/Roo/Kilo on extensions.json (Tier B) Tier-B detector-accuracy follow-up to #193 — fixes the 5 remaining detectors that gated 'installed' on config/globalStorage residue that survives uninstall (or is created by another tool). All 5 doc-verified vs official vendor docs. Extensions (Cline, Roo, Kilo Code): gate on the ext-id being a live entry in <editor>/extensions/extensions.json (the install registry VS Code rewrites on uninstall) via a new shared vscode_extension_helpers.find_extension_in_editor (case-insensitive id — fixes Kilo's lowercase-on-disk kilocode.kilo-code, silently broken on Linux ext4). Dropped the globalStorage check (VS Code won't clean it — microsoft/vscode#119022) and the host-IDE co-check. Roo gains a VSCodium host. CLIs (Cursor CLI, Copilot CLI): gate on the binary. Cursor CLI -> cursor-agent (and fixed the .detect() fallback probing 'cursor' = the IDE launcher, which mis-labeled the IDE as the CLI). Copilot CLI -> the copilot binary (npm + Homebrew, owner- attributed under root); + NEW Linux detector (Copilot CLI was undetected on Linux). install_path is now the binary, so a config_path field carries ~/.copilot and the orchestrator's permissions/skills/ownership key on it (else permissions silently drop). Detection-only, no backend/FE change. Tests: integration-level both-directions per tool/OS incl. globalStorage-residue-without-entry FP-kill, the Cursor IDE-mislabel guard, Copilot hooks-only/machine-global cases, and config_path full-chain attribution. * test(copilot-cli): skip the X_OK non-exec test on Windows test_non_executable_binary_not_detected asserts a non-executable ~/.local/bin/copilot is not detected, but os.access(X_OK) returns True for ANY file on Windows -> the binary reads executable there and the test fails (Windows CI). The X_OK gate is POSIX-only; gate the test with @unittest.skipIf(os.name=='nt'), matching the existing Claude test_non_executable_binary_not_detected skip. macOS (3.9/3.11/3.12) already green. * fix(copilot-cli): get_version uses self._resolve_binary, not the module resolver (Greptile) get_version() resolved the binary via the module-level _resolve_copilot_binary (per-user only: ~/.local/bin, ~/.bun/bin, nvm), but detection uses self._resolve_binary, which Linux/Windows override to add the npm-global prefix, /usr/local/bin, and AppData npm. So a copilot detected via those OS-specific locations reported version 'unknown' (get_version's fallback shells 'copilot --version' on the scanner PATH, empty under root MDM). Route get_version through self._resolve_binary so version resolution matches detection on every OS. * fix(discovery): close 3 binary-coverage gaps — Cursor Win-native, Copilot WinGet + Linuxbrew Coverage verification vs official install docs/scripts found 3 documented install methods the binary resolvers didn't probe -> false negatives: 1. Cursor CLI Windows: the native installer (irm cursor.com/install?win32) writes %LOCALAPPDATA%\cursor-agent\{cursor-agent,agent}.{exe,cmd} + versions\<v>\, but we probed only %USERPROFILE%\.local\bin\cursor-agent.exe -> every native-Win user missed. Added the LOCALAPPDATA paths (existence-gated) + versioned subdir (numeric-newest) + the Git-Bash extensionless ~/.local/bin/cursor-agent. Hoisted _version_key to module level. 2. Copilot CLI Windows: 'winget install GitHub.Copilot' drops a Links shim; verified vs the winget manifest (Commands: [copilot]) the shim is copilot.exe -> added %LOCALAPPDATA%\Microsoft\WinGet\Links\copilot.exe (mirrors the Claude WinGet path). 3. Copilot CLI Linux: 'brew install copilot-cli' is official on Linux (Linuxbrew) -> added ~/.linuxbrew/bin/copilot (user-relative) + /home/linuxbrew/.linuxbrew/bin/copilot (machine-global, owner-attributed under root via machine_global_binary_owned_by_user -- no cross-user FP). /usr/local/bin now also owner-gated under root. Tests: both directions, hermetic; the 6 positive tests fail against pre-fix code. * refactor(copilot-cli): rename internal config_path field -> _config_path (review WARNING) The Copilot CLI detector emits the resolved ~/.copilot dir so the orchestrator can attribute per-user settings/skills/ownership (install_path is the binary now). The field was named 'config_path' (no underscore), so generate_single_tool_report did NOT strip it -> it leaked to the backend payload (exposing the user's home path). Rename to '_config_path' to match the internal-field convention (JetBrains uses _config_path) so it's stripped. Consumed BEFORE the strip (ownership/skills/permissions attribution unchanged), absent from the sent payload. Sites: the emit (macOS copilot_cli, inherited by win/linux) + the 5 orchestrator reads/carry/ log + docstrings. Tests updated; new test_config_path_stripped_from_backend_report asserts the field drives attribution but never reaches the report. * fix(cli): resolve Cursor/Copilot CLI version from the detected binary, not the scanner PATH Both detectors found the binary correctly (install_path) but fetched the VERSION indirectly — Cursor via a bare 'cursor-agent --version' against the scanner's PATH, Copilot by re-resolving via self.user_home — so under a root/MDM all-users scan the version read 'Unknown' even though the binary was found (root's PATH lacks the user's copy). Thread the already-resolved binary into get_version(self, binary=None) (backward-compatible: no-arg keeps the old behaviour); _detect_cursor_cli passes cursor_agent_bin, Copilot _detect_for_user passes the binary it resolved. Probe '<binary> --version' directly — no re-resolve, no bare-PATH fallback. Preserves doc-verified caveats: the --version FLAG (offline, not the network 'version' subcommand); the multi-line-banner parsers; VERSION_TIMEOUT + swallow-to-unknown; the Windows .cmd shim shell=True path (Cursor Windows quotes the spaced path via list2cmdline; Copilot Windows routes through _probe_version). Tests: root-scan version resolution for both (binary off the scanner PATH / self.user_home unset -> version still parsed from the resolved binary), proven non-vacuous; Windows quoted-path + back-compat; existing version tests green. * docs: trim verbose/task-specific comments to brief reason-only (PR #198) Comments should convey the WHY, briefly — not the whole story. Removed task/PR/commit/review references (93b5fc2, W1, doc-verified, device serials, follow-up asides), multi-sentence narratives, and restatements of obvious code from THIS PR's added comments; kept the brief non-obvious reasons (globalStorage survives uninstall, shell=True for the npm .cmd shim, which/npm-prefix root-PATH guards, case-insensitive ext-id, Windows X_OK). Comments/docstrings only — no code/logic changed (AST-verified); suite green (1200). * fix(linux/copilot-cli): don't double-count other users' user-level skills on root scans The Linux Copilot CLI skills extractor called is_user_level_claude_subdir(type_dir) single-arg, which derives the users-root from Path.home(). On an MDM root scan Path.home()==/root (parent /), so a NON-scanner user's /home/<user>/.agents/skills (and .claude/skills) was not recognized as user-level and got re-emitted by the project walk as a project skill -- even though _extract_user_level_skills already emitted it user-scope. Result: duplicate / mis-scoped skill rows on the primary (MDM root) Linux deployment. Add a Linux-aware _is_user_level_skill_dir override (pin users-root to /home for the /home/<user> shape + explicit /root check), mirroring macOS behavior that worked only because all homes share /Users. Ported from PR #157's guard and its TestLinuxRootSkillsUserLevelGuard regression test (this project has no Linux CI runner, so the test is pure path-logic and runs on every runner). Also resolves the open Greptile P2s on this PR: - remove dead _check_ide_installation + now-unused imports (linux/cline, linux/roo_code) and the dead method + orphan IDE_APP_NAMES/APPLICATIONS_DIR (macos/kilocode), left over from dropping the host-editor AND-gate - fix stale class docstrings still describing globalStorage gating (macos/cline, macos/roo_code) and install_path=~/.copilot (windows/copilot_cli) 1269 tests pass (+3 new). Detection-only, no backend/FE change. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: audit <audit@local> * test(WEB-4673): cover extract_dual_path_configs_with_root_support dedup The dual_path root-support helper got the _own_home_already_scanned guard but was the only one of the four without a no-double-count test. Add it (own preferred path read once on the Windows-admin case) plus the macOS-root contrast (root's preferred path, outside /Users, is still read) — full parity with the global helper's coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: NandaPranesh <106886030+anonpran@users.noreply.github.com> Co-authored-by: pugazhendhi-m <132246623+pugazhendhi-m@users.noreply.github.com> Co-authored-by: Pugazhendhi <pugazhendhi@unboundsecurity.ai> Co-authored-by: MohamedAklamaash <aklamaash78@gmail.com> Co-authored-by: vishnu <vishnuvinod072@gmail.com> Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com> Co-authored-by: Mohamed Aklamaash M.R <111295679+MohamedAklamaash@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Nanda Pranesh <nandapranesh27@gmail.com> Co-authored-by: Vignesh Subbiah <51325334+vigneshsubbiah16@users.noreply.github.com> Co-authored-by: Sumit Badsara <sumit@unboundsecurity.ai> Co-authored-by: Sumit Badsara <sumitbadsara.dev@gmail.com> Co-authored-by: audit <audit@local>
…ws), claude_cowork (#202) * fix(discovery): Tier-C residue cleanup — junie binary/plugin gate, copilot-Windows extensions.json, cowork install-gate Three detection-only residue-correctness fixes (follow-up to #198). Each replaces a gate that survives uninstall with a signal that disappears on uninstall. - junie: replace the ~/.junie dir gate (a user-authored guidelines dir that survives uninstall AND misses real installs) with a binary-OR-JetBrains-plugin gate. New find_junie_binary_for_user (mirrors find_claude_binary_for_user, root-owner-attributed) + _detect_junie dispatch; the JetBrains-plugin check is scoped per-user via _scan_jetbrains_config_dir(user_home) (NOT the all-users detect()) + a _path_under_user_home guard, so a root/MDM scan can't fan out one user's plugin to other users. ~/.junie kept only as the version source. - github_copilot (Windows VS Code): read the live extensions.json entry via find_extension_in_editor instead of a github.copilot* folder glob (the folder survives uninstall, microsoft/vscode#81046) — now matches the already-safe macOS/Linux path. - claude_cowork (Windows + Linux): AND an install-presence check (_find_install_dir) with the local-agent-mode-sessions/ dir, in BOTH detect() and the central _detect_claude_cowork (the production root/MDM path); macOS unchanged. Fix the stale factory comment to include Linux. jetbrains residue (config-dir gate) is deferred to a separate ticket (WEB). 1314 tests pass. Detection-only — no backend/FE change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(discovery): skip POSIX-only junie/cowork cases on Windows CI runs unittest discover (all tests, no platform skip). Four new tests are inherently POSIX: the two junie machine-global owner-attribution cases patch pwd.getpwuid (absent on Windows), and the macOS /Applications/Claude.app + Linux /opt/Claude install-path asserts use forward-slash string semantics (backslash on Windows). Guard them with skipIf(os.name == 'nt'), mirroring the existing gemini/claude sibling tests. macOS coverage unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(discovery): reuse is_root sentinel in find_junie_binary_for_user (Greptile P2) Single is_running_as_root() call threaded through the npm-global and PATH backstops instead of re-calling it, matching find_claude_binary_for_user. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(discovery): address Greptile review on cowork — consistent install_path + per-user install dir Two correctness gaps Greptile flagged on the claude_cowork fix: 1. install_path divergence: this PR had changed the Win/Linux OS detect() to report the install dir while the central _detect_claude_cowork (and the untouched macOS detector) report the sessions dir. Revert the OS modules to report sessions_dir so all paths/OSes stay consistent (the pre-PR contract); the resolved install dir remains the GATE, not the reported path. 2. Windows admin/MDM false-negative: WindowsClaudeCoworkDetector._find_install_dir resolved candidates from Path.home() (the scanner's home), so a multi-user admin scan for user B probed the scanner's LocalAppData, missing B's per-user Claude Desktop install. Thread the scanned user_home through _candidate_install_dirs/_find_install_dir, and pass it from the central path. Linux candidates are machine-global (unaffected) but accept the param for a uniform signature. +2 regression tests (admin scan resolves B's home not the scanner's; no cross-user attribution). 1316 tests pass under pytest + unittest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: audit <audit@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--summary was still registered in the production argparse but nothing read args.summary once concise output became the default, so it was a dead, misleading flag — callers passing it silently got default output instead of an error. Remove it so the verbosity group is --dump/--payload only and stale callers fail loudly (matching the test mirror, which had already dropped it). Also fix the now-stale --dump help text (it still claimed to log the JSON payload, which moved to --payload) and de-fragment the comment on the concise-default branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: show concise summary logs
The MCP tool scanner spawns stdio servers verbatim to list their tools. For servers configured via mcp-remote (e.g. npx -y mcp-remote <url>), spawning it without a cached OAuth token makes mcp-remote start an interactive OAuth flow and open a browser tab. On a scheduled discovery scan that surfaces as repeated, unwanted auth tabs. Before scanning, detect mcp-remote stdio configs and check whether mcp-remote already has a cached token for that server (replicating its md5(serverUrl[|resource][|headers]) key, honoring MCP_REMOTE_CONFIG_DIR). If no token exists, skip spawning entirely and report auth_required; the browser flow can only occur if the process runs, and now it never does. When a token is cached, scanning proceeds unchanged and mcp-remote uses it non-interactively (including silent refresh).
…emote fix(discovery): skip mcp-remote scan when it has no cached token
Bump DEFAULT_RUN_TIMEOUT_SECONDS 5400 -> 9000 to allow large fleets / slow scans more headroom before the discovery process self-terminates. Kept in sync with setup/mdm/onboard.py and unbound-cli's discover.js. Co-authored-by: audit <audit@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gent) (#207) * WEB-4855: send real-or-None system_user with scan lifecycle events Split get_user_info into a guaranteed scan-target resolver and a new get_audit_user that returns the real OS user or None (rejecting root, macOS daemons, SYSTEM, Windows machine accounts, Linux service accounts, and "unknown"). Carry it on scan lifecycle events so the backend can attribute empty machines without recording junk identities. Also fixes the pre-existing Windows DOMAIN\\user parsing bug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WEB-4855: reject Windows built-in/service identities in _real_user_or_none Address consensus security review on #207: (1) NT AUTHORITY\\LOCAL SERVICE, NETWORK SERVICE, Administrator and other Windows built-ins survived the filter; add them to the denylist and reject the NT AUTHORITY / NT SERVICE service-principal domains generically. (2) Make _real_user_or_none self-contained by stripping the DOMAIN\\ prefix itself, so the filter is safe regardless of call path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WEB-4855: carry system_user on run-level failed scan events + log None rejection Addresses Cursor 'global failed scan omits system_user' (Medium): initialize the audit system_user to None before the failure closures and pass it on the run-level failed event, so a failure after capture stays attribution-consistent with in_progress/completed (low impact in practice — in_progress already attributes and fill-if-empty protects, but removes the inconsistency). Adds a logger.debug when the audit identity resolves to None (Greptile observability nit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WEB-4855: doc polish — correct get_user_info Windows docstring + note denylist trade-off - get_user_info docstring claimed Windows explorer.exe/Win32/console-session detection the code no longer does; describe the actual whoami+strip behaviour. - Note the _NON_HUMAN_USERS trade-off: a rare human whose login equals a service name is also mapped to None (acceptable — a false None beats a wrong owner). Comment-only, no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WEB-4855: close NT SERVICE\<name> audit bypass on Windows — Greptile get_audit_user() filtered get_user_info(), but get_user_info() pre-strips the DOMAIN\ prefix, so a Windows service principal like NT SERVICE\MSSQLSERVER reached _real_user_or_none as the bare, non-denylisted name "MSSQLSERVER" and leaked through as a human owner. The NT AUTHORITY / NT SERVICE domain rejection never fired. Fix: on Windows, feed the RAW domain-qualified whoami output to _real_user_or_none (it strips the domain itself, after the domain check). Falls back to get_user_info() when whoami is empty. Tests: NT SERVICE\MSSQLSERVER / WinDefend / NT AUTHORITY\* -> None; CORP\alice -> "alice"; empty whoami -> get_user_info fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WEB-4855: fix TestGetAuditUser Windows CI failure (pin platform to non-Windows) get_audit_user() takes a raw-whoami branch on Windows (f1819c4), which bypasses the get_user_info patch these 3 tests rely on, so the Windows runner's real account (runneradmin) leaked through and failed them. Pin platform.system to 'Darwin' so they deterministically exercise the get_user_info path they patch; the Windows raw-whoami branch is already covered by TestGetAuditUserWindowsService. Test-only; no production change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: audit <audit@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hook attaches base64 script body to server_config before dispatch; scan_one forwards it to the reported object so the backend can store it and derive the script:<hash> fingerprint.
* JetBrains detector: name IdeaIE/Aqua, skip non-IDE client folders Add IdeaIE (IntelliJ IDEA Educational) and Aqua to IDE_NAME_MAPPING so they report clean names with versions stripped instead of raw folder names like IdeaIE2022.2 / Aqua2024.1. Align macOS and Linux SKIP_FOLDERS with Windows to exclude the JetBrainsClient remote-dev thin client and consent/DeviceId folders, which are not IDEs and were surfacing as phantom tool rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Treat IntelliJ IDEA Educational (IdeaIE) as a free edition _detect_plan only recognized IdeaIC/PyCharmCE as free, so IdeaIE installs were tagged Licensed. IDEA Educational is free; add it to the free-edition check across all three OS detectors. Aqua is a paid product, so Licensed remains correct for it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: audit <audit@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the skip-if-no-tools guard. URL-less servers (e.g. Claude desktop OAuth remote connectors, which can't be tool-scanned) must still report so the control plane can create metadata and fingerprint/group them. The backend already upserts metadata regardless of tool count. Behavior change: a single-server scan that yields 0 tools now always POSTs.
scan: forward script_content from server config to scan object
…on (#206) Per-user onboarding now scans with the user's own API key, so the scheduled `onboard` run no longer needs a separate discovery key. Stop requiring it at install time and in the cron wrapper (bash + PowerShell); forward it only when one was stored, for older/MDM-style setups. MDM/all-users scheduled scans use the `discover` command and are unchanged. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks) 🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head |
| # (it resolved the path with cwd; we run detached without it). The backend | ||
| # recomputes sha256 -> `script:<hash>` fingerprint and stores the body. | ||
| if obj is not None and isinstance(server_config, dict) and server_config.get('script_content'): | ||
| obj['script_content'] = server_config['script_content'] |
There was a problem hiding this comment.
🔒 Agentic Security Review
Severity: HIGH
Description: The new flow forwards raw script_content from local MCP server config into the outbound mcp_server payload, and this path is explicitly documented as stored server-side. Because these launcher scripts can contain hardcoded secrets or sensitive internal logic, this introduces backend collection of high-sensitivity local script bodies without visible minimization or gating.
Impact: Sensitive local code/config material can be exfiltrated and durably retained in control-plane storage, increasing exposure risk if tokens, credentials, or proprietary logic are embedded in script files.
Reviewed by Cursor Security Reviewer for commit 955b8b8. Configure here.
| if str_args[i] == "--header": | ||
| match = _MCP_REMOTE_HEADER_RE.match(str_args[i + 1]) | ||
| if match: | ||
| headers[match.group(1)] = match.group(2) | ||
| i += 2 | ||
| continue | ||
| if str_args[i] == "--resource": | ||
| resource = str_args[i + 1] | ||
| i += 2 | ||
| continue | ||
| i += 1 |
There was a problem hiding this comment.
_mcp_remote_has_cached_token uses the scanner's home, not the user being scanned
Path.home() resolves to the running process's home directory — /root or /var/root under a root/MDM multi-user scan. When _scan_servers_in_mapping processes configs for a non-root user (e.g., Alice), this function checks /root/.mcp-auth for Alice's token instead of /home/alice/.mcp-auth. If Alice has a valid cached token, the function returns False, and _mcp_remote_unauthed_result reports auth_required — causing Alice's mcp-remote server to be skipped entirely. This affects every non-root user in every root/MDM enterprise deployment that uses mcp-remote.
The fix requires threading user_home from _scan_servers_in_mapping → _mcp_remote_unauthed_result → _mcp_remote_has_cached_token and replacing str(Path.home() / ".mcp-auth") with str(user_home / ".mcp-auth").
| is_root = is_running_as_root() | ||
| for candidate in machine_global + user_relative: |
There was a problem hiding this comment.
machine_global candidates are iterated before user_relative, which is the reverse of the ordering used in find_cursor_agent_binary_for_user. Under a non-root scan on a shared Linux system, a /usr/local/bin/junie installed system-wide (possibly by another user or a package manager) would be returned before checking the scanning user's own ~/.local/bin/junie. Owner-attribution only guards this under root — the check is a no-op for non-root runs. Consider scanning user_relative before machine_global for consistency.
| is_root = is_running_as_root() | |
| for candidate in machine_global + user_relative: | |
| is_root = is_running_as_root() | |
| for candidate in user_relative + machine_global: |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 955b8b8. Configure here.
| if find_install_dir(user_home) is None: | ||
| return None | ||
| except (PermissionError, OSError): | ||
| return None |
There was a problem hiding this comment.
Linux Cowork cross-user false positive
Medium Severity
Multi-user and privileged scans are incorrectly attributing tool installations and cached authentication tokens. For Claude Cowork on Linux, detection uses machine-wide install checks that don't respect the scanned user's home, causing false positives. The mcp-remote auth short-circuit also checks for cached tokens in the scanner's home directory instead of the scanned user's, leading to auth_required errors or incorrect token usage.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 955b8b8. Configure here.


Note
Medium Risk
Changes core detection and multi-user scan paths (Junie, Cowork, Copilot, admin MCP), which can shift reported inventory; mcp-remote and audit-user behavior affect production scan side effects and backend attribution.
Overview
This release hardens coding discovery so reported installs match real software, scan lifecycle events attribute machines to humans (or omit owner), and MCP probing cannot trigger interactive OAuth.
Detection false positives are addressed across several tools. Junie no longer treats
~/.junieas proof of install; it requires the CLI binary (newfind_junie_binary_for_user) or a Junie JetBrains plugin, with per-user IDE scans so another user’s plugin is not attributed. Claude Cowork on Linux/Windows now requires both the session tree and a present Claude Desktop install (config residue after uninstall no longer counts); Windows install probing uses the scanned user’s profile. GitHub Copilot (VS Code) on Windows reads the liveextensions.jsonregistry instead of globbing leftover extension folders. Admin/root MCP extraction skips re-processingPath.home()when that profile is already in theC:\Usersscan (WEB-4673), avoiding duplicate configs on Windows.Audit attribution adds
get_audit_user()/_real_user_or_none()to map service, root,NT SERVICE, and machine accounts toNone, whileget_user_info()stays a non-None string for path building. Scan events (in_progress/completed/failed) optionally includesystem_userwhen a real human is resolved.MCP scan safety: configs that launch
mcp-remotewithout a cached token are not spawned; scans returnauth_requiredinstead of opening a browser OAuth flow.scan_single_mcp_serverforwards hook-providedscript_contentand always attempts reporting (no longer skips zero-tool results).Operator UX: default CLI output is concise (
--summaryremoved);--dumprestores per-merge and report-summary detail via adetail_logger. Scheduled onboard no longer requires--discovery-key(WEB-4891); discovery key remains optional for MDM all-users flows.Claude Cowork is registered for Linux in the tool factory. Extensive unit tests cover residue detection, cross-user Junie attribution, admin home dedup, and audit user filtering.
Reviewed by Cursor Bugbot for commit 955b8b8. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This release adds Linux support for the GitHub Copilot CLI (detector + all five extractors), fixes false-positive tool detection across Junie, Cursor CLI, Copilot CLI, and Claude Cowork by gating on binaries/plugins instead of residual config dirs, introduces a human-identity filter (
get_audit_user) so service/machine accounts are never attributed as scan owners, and resolves a Windows admin double-scan (WEB-4673) where the admin's own profile was counted twice.mcp_extraction_helpers.py): a new pre-scan check prevents spawningmcp-remotewithout a cached OAuth token (which would open a browser tab); the check reads the token cache path fromPath.home()rather than the per-user home, causing falseauth_requiredfor all non-root users under a root/MDM scan.install_pathis now the binary, with a new internal_config_pathfield carrying the config dir used by extractors.utils.py):get_audit_user()returns a real human orNone;system_useris now included in all scan lifecycle events (in_progress, completed, failed);--summaryCLI flag is removed (concise output is now the default).Confidence Score: 3/5
Safe to merge for non-root single-user deployments; root/MDM multi-user scans will silently under-report mcp-remote servers for non-root users until the token-path bug is fixed.
The
_mcp_remote_has_cached_tokenfunction resolves the token cache against the running process's home (Path.home()) rather than the home of the user whose MCP config is being scanned. Under a root/MDM enterprise scan, every non-root user's mcp-remote servers will be markedauth_requiredregardless of whether that user actually has a cached token, causing those servers to be silently skipped. The rest of the changes — binary-gated Junie/Cursor/Copilot detection, the audit-identity filter, the Windows double-scan fix — are well-structured and well-tested.scripts/coding_discovery_tools/mcp_extraction_helpers.py(the new_mcp_remote_has_cached_tokenfunction and its call site in_scan_servers_in_mapping) needs a fix to threaduser_homethrough so the token cache is read from the correct user's home directory.Important Files Changed
_mcp_remote_has_cached_tokenusesPath.home()instead of the scanned user's home, causing false auth_required under root/MDM scans.find_junie_binary_for_user,find_cursor_agent_binary_for_user, and_detect_junie; fixes Cursor CLI and Claude Cowork false-positive detection. Junie binary search iterates machine_global before user_relative (inverted vs cursor-agent).get_audit_user()and_real_user_or_none()for human-identity filtering; fixes a pre-existing Windowsget_user_info()bug whereusernamewas checked before it was assigned. Addssystem_userto scan-event payloads.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Scan Start - main] --> B[get_audit_user] B -->|real human or None| C[send in_progress event with system_user] C --> D[For each tool / user_home] D --> E{tool_name?} E -->|cursor cli| F[find_cursor_agent_binary_for_user] E -->|junie| G[find_junie_binary_for_user] E -->|copilot cli| H[_resolve_binary] E -->|claude cowork| I[sessions_dir + _find_install_dir] F --> J[install_path = binary] G --> J H --> K[install_path = binary, _config_path = ~/.copilot] I --> J K --> L[extractors keyed on _config_path] J --> M[extractors keyed on install_path] L --> N[_scan_servers_in_mapping] M --> N N --> O{mcp-remote config?} O -->|yes| P[_mcp_remote_has_cached_token Path.home/.mcp-auth uses scanner home] P -->|token found| Q[Scan normally] P -->|no token| R[Return auth_required skip spawn] O -->|no| Q Q --> S[generate_single_tool_report] R --> S S --> T[send_report_to_backend with system_user] T --> U[send completed event with system_user]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A[Scan Start - main] --> B[get_audit_user] B -->|real human or None| C[send in_progress event with system_user] C --> D[For each tool / user_home] D --> E{tool_name?} E -->|cursor cli| F[find_cursor_agent_binary_for_user] E -->|junie| G[find_junie_binary_for_user] E -->|copilot cli| H[_resolve_binary] E -->|claude cowork| I[sessions_dir + _find_install_dir] F --> J[install_path = binary] G --> J H --> K[install_path = binary, _config_path = ~/.copilot] I --> J K --> L[extractors keyed on _config_path] J --> M[extractors keyed on install_path] L --> N[_scan_servers_in_mapping] M --> N N --> O{mcp-remote config?} O -->|yes| P[_mcp_remote_has_cached_token Path.home/.mcp-auth uses scanner home] P -->|token found| Q[Scan normally] P -->|no token| R[Return auth_required skip spawn] O -->|no| Q Q --> S[generate_single_tool_report] R --> S S --> T[send_report_to_backend with system_user] T --> U[send completed event with system_user]Reviews (1): Last reviewed commit: "Merge branch 'main' into staging" | Re-trigger Greptile